Вступление

Здравствуйте, дорогие ребята! Сегодня, я, Макар Бадерко расскажу Вам о различных типах графиков, которые могут быть созданы с помощью библиотеки Plotly

Plotly

Plotly - библиотека для визуализации данных, имеющая API для Python, R и JS.

Перед началом работы необходимо импортировать модуль. В разных частях урока для разных задач нам понадобятся как основной модуль, так и один из его подмодулей, поэтому полный набор инструкций импорта у нас:

import plotly import plotly.graph_objs as go import plotly.express as px from plotly.subplots import make_subplots

Так же нам понадобятся библиотеки Pandas и Numpy для работы с сырыми данными

In [1]:
import plotly
import plotly.graph_objs as go
import plotly.express as px
from plotly.subplots import make_subplots

import numpy as np
import pandas as pd

Простейший график

Начнём с простой задачи построения графика по точкам.

Используем функцию $f(x) = x^2$

Сперва поступим совсем просто и "в лоб":

  • Создадим график с помощью функции scatter из подмодуля plotly.express (внутрь передадим 2 списка точек: координаты X и Y)

  • Тут же "покажем" его с помозью метода show()

Обратите внимание - график интерактивный, если навести на него курсор, то можно его приближать и удалять, выделять участки, по наведению курсора на точку получать подробную информацию, возвращать картинку в исходное положение, а при необходимости "скриншотить" и сохранять как файл.

Всё это делается с помощью JS в вашем браузере. А значит, при желании вы можете этим управлять уже после построения фигуры (но мы этого делать пожалуй не будем, т.к. JS != Python)

In [2]:
x = np.arange(0, 5, 0.1)
def f(x):
    return x**2

px.scatter(x=x, y=f(x)).show()
In [3]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x)))
fig.show()

С увеличением длины подписи графика, легенда начала наезжать на график. Мне это не нравится, поэтому перенесём легенду вниз.

Для этого применим к фигуре метод update_layout, у которого нас интересует атрибут legend_orientation fig.update_layout(legend_orientation="h")

In [4]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x),  name='f(x)=x<sup>2</sup>'))
fig.add_trace(go.Scatter(x=x, y=x, name='$$g(x)=x$$'))
fig.update_layout(legend_orientation="h")
fig.show()
In [5]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x),  name='f(x)=x<sup>2</sup>'))
fig.add_trace(go.Scatter(x=x, y=x, name='$$g(x)=x$$'))
fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  title="Plot Title",
                  xaxis_title="x Axis Title",
                  yaxis_title="y Axis Title",
                  margin=dict(l=0, r=0, t=30, b=0))
fig.show()
In [6]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x), mode='lines+markers',  name='f(x)=x<sup>2</sup>'))
fig.add_trace(go.Scatter(x=x, y=x, mode='markers', name='$$g(x)=x$$'))
fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  margin=dict(l=0, r=0, t=0, b=0))
fig.show()
In [7]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x), mode='lines+markers',  name='f(x)=x<sup>2</sup>'))
fig.add_trace(go.Scatter(x=x, y=x, mode='markers',name='g(x)=x',
                         marker=dict(color='LightSkyBlue', size=20, line=dict(color='MediumPurple', width=3))))
fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  hovermode="x",
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()
In [8]:
def h(x):
    return np.sin(x)

def k(x):
    return np.cos(x)

def m(x):
    return np.tan(x)


fig = go.Figure()
fig.update_yaxes(range=[-0.5, 1.5], zeroline=True, zerolinewidth=2, zerolinecolor='LightPink')
fig.update_xaxes(range=[-0.5, 1.5], zeroline=True, zerolinewidth=2, zerolinecolor='#008000')

fig.add_trace(go.Scatter(visible='legendonly', x=x, y=h(x),  name='h(x)=sin(x)'))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=k(x),  name='k(x)=cos(x)'))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=m(x),  name='m(x)=tg(x)'))

fig.add_trace(go.Scatter(x=x, y=f(x), mode='lines+markers',  name='f(x)=x<sup>2</sup>'))
fig.add_trace(go.Scatter(x=x, y=x, mode='markers',name='g(x)=x',
                         marker=dict(color='LightSkyBlue', size=20, line=dict(color='MediumPurple', width=3))))
fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  hovermode="x",
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()
In [9]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x), mode='lines+markers',  name='f(x)=x<sup>2</sup>', 
                         marker=dict(color=h(x), colorbar=dict(title="h(x)=sin(x)"), colorscale='Inferno')
                        ))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=h(x),  name='h(x)=sin(x)'))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=k(x),  name='k(x)=cos(x)'))

fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()

Можно ли добавить больше информации? Конечно можно, но тут возникают хитрости.

Для ещё одного измерения можно использовать размер маркеров.

Важно. Размер - задаётся в пикселях, т.е. величина не отрицательная (в отличие от цвета), поэтому мы будем использовать модуль одной из функций.

так же, величины меньше 2 пикселей обычно плохо видны на экране, поэтому для размера мы добавим множитель.

Размеры задаётся атрибутом size того же словаря внутри marker. Этот атрибут принимает 1 значение (число), либо список (чисел).

In [10]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=f(x), mode='lines+markers',  name='f(x)=x<sup>2</sup>', 
                         marker=dict(color=h(x), colorbar=dict(title="h(x)=sin(x)"), colorscale='Inferno',
                                     size=50*abs(h(x)))
                        ))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=h(x),  name='h(x)=sin(x)'))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=abs(h(x)),  name='h_mod(x)=|sin(x)|'))
fig.add_trace(go.Scatter(visible='legendonly', x=x, y=k(x),  name='k(x)=cos(x)'))

fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()
In [11]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=[x[0]], y=[f(x)[0]], mode='lines+markers',  name='f(x)=x<sup>2</sup>'))

frames=[]
for i in range(1, len(x)):
    frames.append(go.Frame(data=[go.Scatter(x=x[:i+1], y=f(x[:i+1]))]))

fig.frames = frames   

fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  updatemenus=[dict(type="buttons", buttons=[dict(label="Play", method="animate", args=[None])])],
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()

Другой способ задать начальное состояние, слой (с кнопками) и фреймы - сразу передать всё в объект go.Figure:

  • data - атрибут для графика с начальным состоянием

  • layout - описание "декораций" включая кнопки

  • frames - фреймы (кадры) анимации

Мне такой способ кажется менее читаемым за исключением тех случаев, когда вы заранее подготавливаете в отдельных переменных каждый из аргументов до передачи в создаваемую фигуру

In [12]:
fig = go.Figure()
fig.add_trace(go.Scatter(x=[x[0]], y=[f(x)[0]], mode='lines+markers',  name='f(x)=x<sup>2</sup>',
                         marker=dict(color=h(x[0]), colorbar=dict(title="h(x)=sin(x)"), colorscale='Inferno', size=50*abs(h(x[0])))
                        ))

frames=[]
for i in range(1, len(x)):
    frames.append(go.Frame(data=[go.Scatter(x=x[:i+1], y=f(x[:i+1]), marker=dict(color=h(x[:i+1]), size=50*abs(h(x[:i+1]))))]))

fig.frames = frames   

fig.update_layout(legend_orientation="h",
                  legend=dict(x=.5, xanchor="center"),
                  updatemenus=[dict(type="buttons", buttons=[dict(label="Play", method="animate", args=[None])])],
                  margin=dict(l=0, r=0, t=0, b=0))
fig.update_traces(hoverinfo="all", hovertemplate="Аргумент: %{x}<br>Функция: %{y}")
fig.show()
In [13]:
num_steps = len(x)
fig = go.Figure(data=[go.Scatter(x=[x[0]], y=[h(x)[0]], mode='lines+markers', name='h(x)=sin(x)'),
                      go.Scatter(x=[x[0]], y=[k(x)[0]], mode='lines+markers', name='k(x)=cos(x)')])

frames=[]
for i in range(0, len(x)):
    frames.append(go.Frame(name=str(i),
                           data=[go.Scatter(x=x[:i+1], y=h(x[:i+1]), mode='lines+markers', name='h(x)=sin(x)'),
                                 go.Scatter(x=x[:i+1], y=k(x[:i+1]), mode='lines+markers', name='k(x)=cos(x)')])) 

steps = []
for i in range(num_steps):
    step = dict(
        label = str(i),
        method = "animate",
        args = [[str(i)]]
    )
    steps.append(step)

sliders = [dict(
    steps = steps,
)]

fig.update_layout(updatemenus=[dict(direction="left",
                                    x=0.5,
                                    xanchor="center",
                                    y=0,
                                    showactive=False,
                                    type="buttons", 
                                    buttons=[dict(label="▶", method="animate", args=[None, {"fromcurrent": True}]),
                                             dict(label="❚❚", method="animate", args=[[None], {"frame": {"duration": 0, "redraw": False},
                                                                                               "mode": "immediate",
                                                                                               "transition": {"duration": 0}}])])],
                  )


fig.layout.sliders = sliders
fig.frames = frames  

fig.show()

Осталось немного облагородить панель слайдера.

Добавим подписи к графику и осям, увеличим и оформим подпись текущего значения слайдера (в других обстоятельствах он стал бы временной шкалой), сместим кнопки анимации влевой, а слайдер чуть сожмём, чтобы освободить им место.

  • Аргумент currentvalue - задаёт форматирование подписи к текущему шагу, включая префикс, положение на слайде, шрифт

  • Аргументы x, y, xanchor, yanchor, pad - задают положение и отступы для слайдера и их синтаксис аналогичен таковому у кнопок

In [14]:
num_steps = len(x)
fig = go.Figure(data=[go.Scatter(x=[x[0]], y=[h(x)[0]], mode='lines+markers', name='h(x)=sin(x)',
                                 marker=dict(color=[f(x[0])], colorbar=dict(yanchor='top', y=0.8, title="f(x)=x<sup>2</sup>"), colorscale='Inferno', size=[50*abs(h(x[0]))])),
                      go.Scatter(x=[x[0]], y=[k(x)[0]], mode='lines+markers', name='k(x)=cos(x)',
                                 marker=dict(color=[f(x[0])], colorscale='Inferno', size=[50*abs(k(x[0]))]))])

frames=[]
for i in range(0, len(x)):
    frames.append(go.Frame(name=str(i),
                           data=[go.Scatter(x=x[:i+1], y=h(x[:i+1]), mode='lines+markers', name='h(x)=sin(x)',
                                            marker=dict(color=f(x[:i+1]), colorscale='Inferno', size=50*abs(h(x[:i+1])))),
                                 go.Scatter(x=x[:i+1], y=k(x[:i+1]), mode='lines+markers', name='k(x)=cos(x)',
                                            marker=dict(color=f(x[:i+1]), colorscale='Inferno', size=50*abs(k(x[:i+1]))))])) 

steps = []
for i in range(num_steps):
    step = dict(
        label = str(i),
        method = "animate",
        args = [[str(i)]]
    )
    steps.append(step)

sliders = [dict(
    currentvalue = {"prefix": "Шаг №", "font": {"size": 20}},
    len = 0.9,
    x = 0.1,
    pad = {"b": 10, "t": 50},
    steps = steps,
)]

fig.update_layout(title="Строим синус и косинус по шагам",
                  xaxis_title="Ось X",
                  yaxis_title="Ось Y",
                  updatemenus=[dict(direction="left",
                                    pad = {"r": 10, "t": 80},
                                    x = 0.1,
                                    xanchor = "right",
                                    y = 0,
                                    yanchor = "top",
                                    showactive=False,
                                    type="buttons", 
                                    buttons=[dict(label="▶", method="animate", args=[None, {"fromcurrent": True}]),
                                             dict(label="❚❚", method="animate", args=[[None], {"frame": {"duration": 0, "redraw": False},
                                                                                               "mode": "immediate",
                                                                                               "transition": {"duration": 0}}])])],
                  )


fig.layout.sliders = sliders
fig.frames = frames  

fig.show()

Возникает вопрос Зачем же мы делали вариант с невидимыми графиками, если они не пригодились?

На самом деле они нужны, в том числе для анимации. Дело в том, что если вы хотите на разных слайдах анимации показывать разное количество графиков, то вам надо в самом начале на этапе создания фигуры добавить столько графиков, сколько их может отображаться максимально. Они все Должны быть невидимыми.

Я специально задам 2 переменные:

  • graphs_invisible - содержит как невидимый корректный график, так и пустой объект графика вообще без указания видимости

  • graphs_visible - содержит корректные видимые графики, которые надо показывать по очереди

В первоначальном состоянии мы отобразим невидимые графики или пустышки, а в каждом фрейме будем комбинировать видимые и невидимые, чтобы их количество было постоянным.

In [15]:
graphs_invisible = [go.Scatter(visible = False, x=x, y=f(x),  name='f(x)=x<sup>2</sup>'),
                    go.Scatter(visible = False, x=x, y=x,  name='g(x)=x'),
                    go.Scatter(visible = False, x=x, y=h(x), name='h(x)=sin(x)'),
                    go.Scatter(visible = False, x=x, y=k(x), name='k(x)=cos(x)')]
graphs_visible = [go.Scatter(visible = True, x=x, y=f(x), name='f(x)=x<sup>2</sup>'),
                  go.Scatter(visible = True, x=x, y=x,  name='g(x)=x'),
                  go.Scatter(visible = True, x=x, y=h(x), name='h(x)=sin(x)'),
                  go.Scatter(visible = True, x=x, y=k(x), name='k(x)=cos(x)')]
fig = go.Figure(data=graphs_invisible)

frames=[]
for i in range(len(graphs_visible)+1):
    frames.append(go.Frame(name=str(i),
                           data=graphs_visible[:i]+graphs_invisible[i:])) 

steps = []
for i in range(len(graphs_visible)+1):
    step = dict(
        label = str(i),
        method = "animate",
        args = [[str(i)]]
    )
    steps.append(step)

sliders = [dict(
    currentvalue = {"prefix": "Графиков отображается: ", "font": {"size": 20}},
    len = 0.9,
    x = 0.1,
    pad = {"b": 10, "t": 50},
    steps = steps,
)]

fig.update_layout(title="Выводим графики по очереди",
                  xaxis_title="Ось X",
                  yaxis_title="Ось Y",
                  updatemenus=[dict(direction="left",
                                    pad = {"r": 10, "t": 80},
                                    x = 0.1,
                                    xanchor = "right",
                                    y = 0,
                                    yanchor = "top",
                                    showactive=False,
                                    type="buttons", 
                                    buttons=[dict(label="▶", method="animate", args=[None, {"fromcurrent": True}]),
                                             dict(label="❚❚", method="animate", args=[[None], {"frame": {"duration": 0, "redraw": False},
                                                                                               "mode": "immediate",
                                                                                               "transition": {"duration": 0}}])])],
                  )


fig.layout.sliders = sliders
fig.frames = frames  

fig.show()

Круговые диаграммы

Для полноты картины рассмотрим несколько других способов визуализации данных, кроме линейных графиков. Начнём с круговых диаграмм

Для нашего эксперимента "подбросим" 100 раз пару игральных кубиков (костей) и запишем суммы выпавших очков.

In [16]:
dices = pd.DataFrame(np.random.randint(low=1, high=7, size=(100, 2)), columns=('Кость 1', 'Кость 2'))


dices['Сумма'] = dices['Кость 1'] + dices['Кость 2'] 
# Первые 5 бросков игральных костей
display(dices.head())

sum_counts = dices['Сумма'].value_counts().sort_index()
# количество выпавших сумм
display(sum_counts)
Кость 1 Кость 2 Сумма
0 3 2 5
1 3 2 5
2 1 6 7
3 2 5 7
4 2 1 3
2      3
3      7
4      9
5      8
6     10
7     16
8     21
9     11
10     6
11     2
12     7
Name: Сумма, dtype: int64

Для того чтобы создать круговую диаграмму используем go.Pie, который добавляем так же, как мы добавляли график на созданную фигуру.

Используем 2 основных атрибута:

  • values - размер сектора диаграммы, в нашем случае прямо пропорционален количеству той или иной суммы

  • labels - подпись сектора, в нашем случае значение суммы. Если не передать подпись, то в качестве подписи будет взят индекс значения из списка values

In [17]:
fig = go.Figure()
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index))
fig.show()

Сразу бросается в глаза то, что хотя мы передали массив, упорядоченный по индексам, но при построении он был пересортирован по значениям.

Это легко исправить с помощью аргумента

sort = False

In [18]:
fig = go.Figure()
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index, sort = False))
fig.show()

Так же при желании мы можем "выдвинуть" один или несколько секторов.

Для этого используем аргумент pull, который принимаем список чисел. Каждое число - доля, на которую надо выдвинуть сектор из круга:

  • 0 - не выдвигать

  • 1 - 100% радиуса круга

Мы создадим список из нулей, такой же длинны, что массив значений. А потом один элемент увеличим до 0.2.

Обратите внимание, мы не используем метод idxmax Pandas, т.к. наш массив имеет индексы, соответствующие суммам. А определение какой сектор выдвигать на диаграмме происходит по индексу списка, к которому наш массив приводится.

In [19]:
fig = go.Figure()
pull = [0]*len(sum_counts)
pull[sum_counts.tolist().index(sum_counts.max())] = 0.2
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index, pull=pull))
fig.show()

Если вам не нравятся классические круговые диаграммы "пирожки", то легко превратить их в "пончики", вырезав сердцевину. Для этого используем аргумент hole, в который передаём число (долю радиуса, которую надо удалить):

  • 0 - не вырезать ничего

  • 1 - 100% вырезать, ничего не оставить

Таким образом, значение 0.9 превратит круговую диаграмму в кольцевую.

In [20]:
fig = go.Figure()
pull = [0]*len(sum_counts)
pull[sum_counts.tolist().index(sum_counts.max())] = 0.2
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index, pull=pull, hole=0.9))
fig.show()

Кстати, образовавшаяся "дырка от бублика" - идеальное место для подписи, которую можно сделать с помощью атрибута annotations слоя.

Не забываем, что аннотаций может быть много, поэтому annotations принимаем список словарей.

Текст аннотации поддерживает HTML разметку (чем мы воспользуемся, задав абсурдно длинный текст, не помещающийся в 1 строку)

In [21]:
fig = go.Figure()
pull = [0]*len(sum_counts)
pull[sum_counts.tolist().index(sum_counts.max())] = 0.2
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index, pull=pull, hole=0.9))

fig.update_layout(
    annotations=[dict(text='Суммы очков<br>при броске<br>2 игральных костей', x=0.5, y=0.5, font_size=20, showarrow=False)])
fig.show()

Естественно обычный способы оформления визуализаций, показанные для графиков, тут тоже работают:

  • title

  • title_x

  • margin

  • legend_orientation

In [22]:
fig = go.Figure()
pull = [0]*len(sum_counts)
pull[sum_counts.tolist().index(sum_counts.max())] = 0.2
fig.add_trace(go.Pie(values=sum_counts, labels=sum_counts.index, pull=pull, hole=0.9))

fig.update_layout(
    title="Пример кольцевой/круговой диаграммы",
    title_x = 0.5,
    margin=dict(l=0, r=0, t=30, b=0),
    legend_orientation="h",
    annotations=[dict(text='Суммы очков<br>при броске<br>2 игральных костей', x=0.5, y=0.5, font_size=20, showarrow=False)])
fig.show()

Что, если вы хотим детализовать картинку?

Sunburst или диаграмма "солнечные лучи"

Нам на помощь приходит диаграмма "солнечные лучи" - иерархическая диаграмма на основе круговой. По сути это набор кольцевых диаграмм, нанизанных друг на друга, причём сегменты следующего уровня находятся в пределах границ сегментов своего "родителя" на предыдущем.

Например, получить 8 очков с помощью 2 игральных костей можно несколькими способами:

  • 2 + 6

  • 3 + 5

  • 4 + 4

Для построения диаграммы нам потребуется go.Sunburst и 4 основных аргумента:

  • values - значения, задающие долю от круга на диаграмме

  • branchvalues="total" - такое значение указывает, что значения родительского элемента являются суммой значений потомков. Это необходимо для того, чтобы составить полный круг на каждом уровне.

  • labels - список подписей, которые отображаются на диаграмме

  • parents - список подписей родителей, для построения иерархии. Для элементов 0 уровня (без родителей) родителем указывается пустая строка.

Для начала обойдёмся 2 уровнями (все события и суммы)

In [23]:
# 1-й уровень, центр диаграммы
labels = ["Всего событий: " + str(sum(sum_counts))]
parents = [""]
values = [sum(sum_counts)]

# 2-й уровень, "лепестки" диаграммы
second_level_dict = {x:'Событий: ' + str(sum_counts[x]) + '<br>Σ = ' + str(x) for x in sum_counts.index}
labels += map(lambda x: second_level_dict[x], sum_counts.index)
parents += [labels[0]]*len(sum_counts)
values += sum_counts.tolist()

fig = go.Figure(go.Sunburst(
    labels = labels,
    parents = parents,
    values=values,
    branchvalues="total"
))
#fig.update_layout(margin = dict(t=0, l=0, r=0, b=0))

fig.show()

А теперь добавим группировку по парам исходов игральных костей и вычисление для таких пар "родителей".

Конечно, если кости идентичны, то 6+2 и 2+6 - это идентичные исходы, как и пара 3+5 и 5+3, но в рамках следующего примера мы будем считать их разными, просто чтобы не добавлять лишнего кода.

Так же уменьшим отступы, т.к. подписи получаются уж очень мелкими.

In [24]:
# 1-й уровень, центр диаграммы
labels = ["Всего событий: " + str(sum(sum_counts))]
parents = [""]
values = [sum(sum_counts)]

# 2-й уровень, "промежуточный"
second_level_dict = {x:'Событий: ' + str(sum_counts[x]) + '<br>Σ = ' + str(x) for x in sum_counts.index}
labels += map(lambda x: second_level_dict[x], sum_counts.index)
parents += [labels[0]]*len(sum_counts)
values += sum_counts.tolist()

# Готовим DataFrame для 3 уровня (группируем )
third_level = dices.groupby(['Кость 1', 'Кость 2']).count().reset_index()
third_level.rename(columns={'Сумма':'Value'}, inplace=True)
third_level['Сумма'] = third_level['Кость 1'] + third_level['Кость 2']
third_level['Label'] = third_level['Кость 1'].map(str) + ' + ' + third_level['Кость 2'].map(str)
third_level['Parent'] = third_level['Сумма'].map(lambda x: second_level_dict[x])
# 3-й уровень, "лепестки" диаграммы
values += third_level['Value'].tolist()
parents += third_level['Parent'].tolist()
labels += third_level['Label'].tolist()

fig = go.Figure(go.Sunburst(
    labels = labels,
    parents = parents,
    values=values,
    branchvalues="total"
))
fig.update_layout(margin = dict(t=0, l=0, r=0, b=0))

fig.show()

Гистограммы

Естественно не круговыми диаграммами едиными, иногда нужны и обычные столбчатые.

Простейшая гистограмма строится с помощью go.Histogram. В качестве единственного аргумента в x передаём список значений, которые участвуют в выборке (Plotly самостоятельно сгруппирует их в столбцы и вычислит высоту), в нашем случае это колонка с суммами.

In [25]:
fig = go.Figure(data=[go.Histogram(x=dices['Сумма'])])
fig.show()

Если по какой-то причине нужно построить не вертикальную, а горизонтальную гистограмму, то меняем x на y:

In [26]:
fig = go.Figure(data=[go.Histogram(y=dices['Сумма'])])
fig.show()

А что, если у нас 2 или 3 набора данных и мы хотим их сравнить? Сгенерируем ещё 1100 бросков пар кубиков и просто добавим на фигуру 2 гистограммы:

In [27]:
dices2 = pd.DataFrame(np.random.randint(low=1, high=7, size=(100, 2)), columns=('Кость 1', 'Кость 2'))
dices2['Сумма'] = dices2['Кость 1'] + dices2['Кость 2']
dices3 = pd.DataFrame(np.random.randint(low=1, high=7, size=(1000, 2)), columns=('Кость 1', 'Кость 2'))
dices3['Сумма'] = dices3['Кость 1'] + dices3['Кость 2']

fig = go.Figure()
fig.add_trace(go.Histogram(x=dices['Сумма']))
fig.add_trace(go.Histogram(x=dices2['Сумма']))
fig.add_trace(go.Histogram(x=dices3['Сумма']))
fig.show()

Все 3 выборки подчиняются одному и тому же распределению, и очевидно, но количество событий сильно отличается, поэтому на нашей гистограмме некоторые столбцы сильно больше других.

Картинку надо "нормализовать". Для этого служит аргумент histnorm.

In [28]:
fig = go.Figure()
fig.add_trace(go.Histogram(x=dices['Сумма'], histnorm='probability density'))
fig.add_trace(go.Histogram(x=dices2['Сумма'], histnorm='probability density'))
fig.add_trace(go.Histogram(x=dices3['Сумма'], histnorm='probability density'))
fig.show()

Как и предыдущие виды визуализаций, гистограммы могут иметь оформление:

  • подпись графика, подписи осей

  • ориентация и положение легенды.

  • отступы

In [29]:
fig = go.Figure()
fig.add_trace(go.Histogram(x=dices['Сумма'], histnorm='probability density', name='100 бросков v.1'))
fig.add_trace(go.Histogram(x=dices2['Сумма'], histnorm='probability density', name='100 бросков v.2'))
fig.add_trace(go.Histogram(x=dices3['Сумма'], histnorm='probability density', name='1000 бросков'))
fig.update_layout(
    title="Пример гистограммы на основе бросков пары игральных костей",
    title_x = 0.5,
    xaxis_title="сумма очков",
    yaxis_title="Плотность вероятности",
    legend=dict(x=.5, xanchor="center", orientation="h"),
    margin=dict(l=0, r=0, t=30, b=0))

fig.show()

Другой интересны режим оформления - barmode='overlay' - он позволяет рисовать столбцы гистограммы одни поверх других.

Имеет смысл использовать его одновременно с аргументом opacity самих гистограмм - он задаёт прозрачность гистограммы (от 0 до 100%).

Однако, большое количество гистограмм в таком случае тяжело визуально интерпретировать, поэтому мы скроем одну.

In [30]:
fig = go.Figure()
fig.add_trace(go.Histogram(x=dices['Сумма'], histnorm='probability density', opacity=0.75, name='100 бросков v.1'))
fig.add_trace(go.Histogram(x=dices2['Сумма'], histnorm='probability density', opacity=0.75, name='100 бросков v.2'))
#fig.add_trace(go.Histogram(x=dices3['Сумма'], histnorm='probability density', opacity=0.75, name='1000 бросков'))
fig.update_layout(
    title="Пример гистограммы на основе бросков пары игральных костей",
    title_x = 0.5,
    xaxis_title="сумма очков",
    yaxis_title="Плотность вероятности",
    legend=dict(x=.5, xanchor="center", orientation="h"),
    barmode='overlay',
    margin=dict(l=0, r=0, t=30, b=0))

fig.show()

Если мы говорим о вероятности, то имеет так же смысл построить и накопительную гистограмму. Например, вероятности выпадения не менее чем X очков на сумме из 2 игральных костей.

Для этого используется аргумент гистограммы cumulative_enabled=True

In [31]:
fig = go.Figure()
fig.add_trace(go.Histogram(x=dices['Сумма'], histnorm='probability density', cumulative_enabled=True, name='100 бросков v.1'))
fig.add_trace(go.Histogram(x=dices2['Сумма'], histnorm='probability density', cumulative_enabled=True, name='100 бросков v.2'))
fig.add_trace(go.Histogram(x=dices3['Сумма'], histnorm='probability density', cumulative_enabled=True, name='1000 бросков'))
fig.update_layout(
    title="Пример накопительной гистограммы на основе бросков пары игральных костей",
    title_x = 0.5,
    xaxis_title="сумма очков",
    yaxis_title="Вероятность",
    legend=dict(x=.5, xanchor="center", orientation="h"),
    margin=dict(l=0, r=0, t=30, b=0))

fig.show()

Так же весьма полезно то, что на одной фигуре можно совмещать график, построенный по точкам (go.Scatter) и гистограмму (go.Histogram).

Для демонстрации такого применения, давайте сгенерируем 1000 событий из другого распределения - нормального. Для него легко построить теоретическую кривую. Мы возьмём для этого готовые функции из модуля scipy:

  • scipy.stats.norm.rvs - для генерации событий

  • scipy.stats.norm.pdf - для получения теоретический функции распределения

В качестве начального и конечного значений аргумента (x) возьмём границы интервала в 3σ

In [32]:
from scipy.stats import norm
r = norm.rvs(size=1000)

x_norm = np.linspace(norm.ppf(0.01), norm.ppf(0.99), 100)

fig = go.Figure()
fig.add_trace(go.Histogram(x=r, histnorm='probability density', name='"Экспериментальные" данные'))
fig.add_trace(go.Scatter(x=x_norm, y=norm.pdf(x_norm), name='Теоретическая форма нормального распределения'))
fig.update_layout(
    title="Пример гистограммы на основе нормального распределения",
    title_x = 0.5,
    legend=dict(x=.5, xanchor="center", orientation="h"),
    margin=dict(l=0, r=0, t=30, b=0))

fig.show()

Этот пример так же демонстрирует как происходит объединение в столбцы, если величина не дискретная.

В данном случае каждый столбец тем выше, чем больше значений попало в интервал, соответствующий ширине этого столбца.

В свою очередь это означает, что при необходимости мы можем регулировать количество столбцов и их ширину (это 2 взаимосвязанных параметра).

  • Вариант 1 - задав ширину столбца - xbins={"size":0.1}

  • Вариант 2 - задав количество столбцов - nbinsx=200

In [33]:
fig = go.Figure()
fig.add_trace(go.Histogram(nbinsx=200,
                           x=r, histnorm='probability density', name='"Экспериментальные" данные'))
fig.add_trace(go.Scatter(x=x_norm, y=norm.pdf(x_norm), name='Теоретическая форма нормального распределения'))
fig.update_layout(
    title="Пример гистограммы на основе нормального распределения",
    title_x = 0.5,
    legend=dict(x=.5, xanchor="center", orientation="h"),
    margin=dict(l=0, r=0, t=30, b=0))

fig.show()

Другие столбчатые диаграммы - Bar Charts

Столбчатые диаграммы можно сформировать и своими силами, если сгруппировать данные и вычислить высоты столбцов.

Далее, используя класс go.Bar передаём названия столбцов и их величины в 2 аргумента:

  • x - подписи

  • y - величины

In [34]:
d_grouped = dices.groupby(['Сумма']).count()

labels = d_grouped.index
values = d_grouped['Кость 1'].values

fig = go.Figure(data=[go.Bar(x = labels, y = values)])
fig.show()

Важно!

Как и круговая диаграмма, такая столбчатая в отличие от ранее изученных гистограмм не построит столбец для того, чего нет!

Например, если мы сделаем только 10 бросков по 2 кости, то среди них не может выпасть всех возможных случаев. А значит, они не отобразятся на диаграмме:

In [35]:
BAD_d_grouped = dices.head(10).groupby(['Сумма']).count()

labels = BAD_d_grouped.index
values = BAD_d_grouped['Кость 1'].values

fig = go.Figure(data=[go.Bar(x = labels, y = values)])
fig.show()

При необходимости выведения ВСЕХ, даже нулевых столбцов, их следует сформировать самостоятельно.

Создадим парную гистограмму для 2 наборов по 100 бросков, в оба набора добавив на всякий случай колонки с нулями, если их нет.

В зависимости от генерации начальных данных в каких-то местах должна быть только 1 колонка, либо не будет колонок вообще.

Ящики с усами (Box Plots)

А что, если требуется более сложный и информативный инструмент? Примером может служить диаграмма размаха или "ящик с усами" (https://habr.com/ru/post/267123/)

Для примера создадим набор 100 событий с бросками набора других игральных костей. На этот раз 3 4-гранных кости (3d4). Это могло бы быть сравнением 2 игровых мечей с уроном 2d6 и 3d4, однако, любому очевидно, что второй эффективнее (разброс 2-12 против разброса 3-12). Вся ли это информация, которую можно "вытащить" из этих данных?

Конечно нет, ведь у них будут отличаться и меры центральной тенденции (медианы или средние).

Для построения ящиков с усами мы используем класс go.Box. Данные (весь массив "сумм") передаём в единственный аргумент - y.

In [36]:
dices4 = pd.DataFrame(np.random.randint(low=1, high=5, size=(100, 3)), columns=('Кость 1', 'Кость 2', 'Кость 4'))
dices4['Сумма'] = dices4['Кость 1'] + dices4['Кость 2'] + dices4['Кость 4']

fig = go.Figure()
fig.add_trace(go.Box(y=dices['Сумма']))
fig.add_trace(go.Box(y=dices4['Сумма']))
fig.show()

Не совсем понятно кто есть кто.

Примечание. Т.к. мы используем random, то в вашем случае результат может получиться не такой, как у меня при тестовой генерации, однако забавно, что с первой же попытки во время подготовки этого материала я получил вот такую картинку:

Тут ясно, что "усы" левого ящика имеют размах 2-12, значит, это и есть 2d6. Но занятно, что хотя нижняя граница прямого "усы" выше левого, но и верхняя ниже! Это объясняется тем, что 100 событий не так уж и много, а выбросить сразу 3 четвёрки довольно сложно. И медианы у них на одном уровне. Выходит, наше первоначальное предположение о большей эффективности оружия с уроном 3d4 можно считать справедливым лишь по уровню 25% квартиля - он явно выше на правой картинке. Т.е. "ящик с усами" всё же дал нам довольно много легко считываемой и не совсем очевидной первоначально информации.

Однако, как и для других фигур, тут можно задать подписи.

In [37]:
fig = go.Figure()
fig.add_trace(go.Box(y=dices['Сумма'], name='2d6'))
fig.add_trace(go.Box(y=dices4['Сумма'], name='3d4'))
fig.update_layout(title="Сравнение испытаний по 100 бросков игральных костей",
                  xaxis_title="Вид испытаний",
                  yaxis_title="Cумма очков")
fig.show()

Иногда вертикальные ящики не очень наглядны (либо сложно прочитать подписи снизу), тогда их можно положить "на бок" так же, как мы делали с обычными столбчатыми диаграммами:

In [38]:
fig = go.Figure()
fig.add_trace(go.Box(x=dices['Сумма'], name='2d6'))
fig.add_trace(go.Box(x=dices4['Сумма'], name='3d4'))
fig.update_layout(title="Сравнение испытаний по 100 бросков игральных костей",
                  yaxis_title="Вид испытаний",
                  xaxis_title="Cумма очков")
fig.show()

Иногда полезно для каждого ящика с усами так же отобразить облако точек, формирующий распределение. Это легко сделать с помощью аргумента boxpoints='all'

In [39]:
fig = go.Figure()
fig.add_trace(go.Box(x=dices['Сумма'], name='2d6', boxpoints='all'))
fig.add_trace(go.Box(x=dices4['Сумма'], name='3d4', boxpoints='all'))
fig.update_layout(title="Сравнение испытаний по 100 бросков игральных костей",
                  yaxis_title="Вид испытаний",
                  xaxis_title="Cумма очков")
fig.show()

Географические карты

Plotly поддерживает великое множество разных видов визуализаций, охватить все из которых в одном обзоре довольно трудно (и бессмысленно, т.к. общие принципы будут схожи с ранее показанными)

Полезно будет в завершении лишь показать один из наиболее красивых на мой взгляд "графиков" - Scattermapbox - геокарты.

Для этого возьмём CSV с 1117 населёнными пунктами РФ и их координатами (файл создан на основе https://github.com/hflabs/city/blob/master/city.csv) - 'https://raw.githubusercontent.com/hflabs/city/master/city.csv.

Воспользуемся классом go.Scattermapbox и 2 атрибутами:

  • lat (широта)

  • lon (долгота)

Так же нам понадобится подключить OSM карту, т.к. Scattermapbox может работать с разными видами карт:

fig.update_layout(mapbox_style="open-street-map")

In [40]:
cities = pd.read_csv('https://raw.githubusercontent.com/hflabs/city/master/city.csv')
fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], lon=cities['geo_lon']))
fig.update_layout(mapbox_style="open-street-map")
fig.show()

Как-то криво, правда? Давайте сдвинем центр карты так, чтобы он пришёлся на столицу нашей родины (вернее столицу родины автора этих строк, т.к. у читателя родина может быть иной).

Для этого нам понадобится объект go.layout.mapbox.Center или обычный словарь с 2 аргументами:

  • lat

  • lon

Этот объект/словарь мы передаём в качестве значения аргумента center словаря внутрь mapbox:

fig.update_layout( mapbox=dict( center=dict( lat=..., lon=... ) ) )

In [41]:
fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], lon=cities['geo_lon']))
capital = cities[cities['region']=='Москва']
map_center = go.layout.mapbox.Center(lat=capital['geo_lat'].values[0], lon=capital['geo_lon'].values[0])
# Аналог с помощью словаря
#map_center =                   dict(lat=capital['geo_lat'].values[0], lon=capital['geo_lon'].values[0])

fig.update_layout(mapbox_style="open-street-map", mapbox=dict(center=map_center))
fig.show()

Неплохо, но масштаб мелковат (по сути сейчас отображается карта мира на которой 1/6 часть суши занимает далеко не всё полезное место).

Без ущерба для полезной информации можно слегка приблизить картинку.

Для этого используем аргумент zoom=2

In [42]:
fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], lon=cities['geo_lon']))
capital = cities[cities['region']=='Москва']
map_center = go.layout.mapbox.Center(lat=capital['geo_lat'].values[0], lon=capital['geo_lon'].values[0])
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

Увы, на карту попало слишком много Европы без данных и слишком мало отечественного дальнего востока, так что в данном случае центрироваться возможно стоит по геометрическому центру страны (вычислим его весьма "приблизительно").

In [43]:
fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], lon=cities['geo_lon']))

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

Давайте добавим подписи городов. Для этого используем аргумент text.

Следует заметить, что для нескольких населённых пунктов (города федерального значения) почему-то не заполнено поле city, поэтому для них мы его вручную заполним из address. Не очень красиво, но главное, что не пустота.

In [44]:
cities.loc[cities['city'].isna(), 'city'] = cities.loc[cities['city'].isna(), 'address']

fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], lon=cities['geo_lon'], text=cities['city']))
map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

Вспомним, как мы увеличивали плотность информации для обычных графиков. Тут так же можно задать размер маркера, например, от населения.

Следует учесть 2 момента:

  1. Данные замусорены. Население некоторых городов имеет вид 96[3]. Поэтому колонка с население не численная и нам нужна функция, которая этот мусор обнулит, либо приведёт к какому-то читаемому виду.

  2. Размер маркера задаётся в пикселях. И 15 миллионов пикселей - слишком большой диаметр. Потому разумно придумать формулу, например, логарифм.

In [45]:
def to_int_size(value):
    try:
        return np.log10(int(value))
    except:
        return np.log10(int(value.split('[')[0]))

fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(size=cities['population'].map(to_int_size))))
map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

Добавим цветовую кодировку. Для этого используем данные о годе основания. Т.к. не для всех городов он точно известен (для некоторых указан век, для некоторых римскими, а не арабскими цифрами), то мы так же вынуждены будем написать свою функцию для обработки годов, но для простоты все проблемные случаи мы будем возвращать None и потом просто удалим все такие города.

Если возвращать, например, np.NaN, то при построении тепловой карты эти значения будут считаться эквивалентными 0 и мы будем считать такие населённые пункты одними из самых старых в стране)

In [46]:
def to_int_year(value):
    try:
        return int(value)
    except:
        return None

cities['foundation_year'] = cities['foundation_year'].map(to_int_year)
cities = cities[['region', 'city', 'geo_lat', 'geo_lon', 'foundation_year', 'population']].dropna()

fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(colorbar=dict(title="Год основания"),
                                             color=cities['foundation_year'],
                                             size=cities['population'].map(to_int_size))))
map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

А что если мы хотим нанести линию? Без проблем!

Возьмём и добавим новый график на имеющуюся картинку, который будет содержать только 2 точки: Москву и Санкт-Петербург.

Нам понадобится новый атрибут mode = "lines" (у него доступны и другие значения, например "markers+lines"), но мы уже вывели метку города, так что не хотим её дублировать.

  • не будем выводить никакой информации при наведении на этот новый график, чтобы она не перебивала эффект наведения на основные точки. hoverinfo='skip'
In [47]:
fig = go.Figure(go.Scattermapbox(lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(colorbar=dict(title="Год основания"),
                                             color=cities['foundation_year'].map(to_int_year),
                                             size=cities['population'].map(to_int_size))))
fig.add_trace(go.Scattermapbox(mode = "lines",
                               hoverinfo='skip',
                               lat=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lat'],
                               lon=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lon']))    

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))
fig.show()

Ох, кажется полоска тепловой карты наложилась на легенду. Более того, теперь легенда выводится раздельная для точек-городов и линии между Москвой и Санкт-Петербургом.

  1. Переключим легенду в горизонтальный режим legend_orientation="h" (в настройках слоя)

  2. "сгруппируем" легенды вместе. Для этого у каждого графика группы добавим аргумент legendgroup="group" (можно использовать любые строки, лишь бы они были одинаковые у членов одной группы).

In [48]:
fig = go.Figure(go.Scattermapbox(legendgroup="group",
                                 lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(colorbar=dict(title="Год основания"),
                                             color=cities['foundation_year'].map(to_int_year),
                                             size=cities['population'].map(to_int_size))))
fig.add_trace(go.Scattermapbox(legendgroup="group",
                               mode = "lines",
                               hoverinfo='skip',
                               lat=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lat'],
                               lon=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lon']))    

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(legend_orientation="h",
                  mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))

fig.show()

Отлично, теперь они включаются и выключаются вместе. Давайте уберём из легенды "лишний" элемент (линию городов) showlegend=False

А так же подпишем легенду для городов.

In [49]:
fig = go.Figure(go.Scattermapbox(legendgroup="group",
                                 name='Города России',
                                 lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(colorbar=dict(title="Год основания"),
                                             color=cities['foundation_year'].map(to_int_year),
                                             size=cities['population'].map(to_int_size))))
fig.add_trace(go.Scattermapbox(legendgroup="group",
                               showlegend=False,
                               mode = "lines",
                               hoverinfo='skip',
                               lat=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lat'],
                               lon=cities[cities['region'].isin(['Санкт-Петербург', 'Москва'])]['geo_lon']))    

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(legend_orientation="h",
                  mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))

fig.show()

Давайте добавим чуть более осмысленные линии на карту. Для этого воспользуемся маршрутом поезда №002М "Россия" Москва-Владивосток - https://pass.rzd.ru/timetable/public/ru?STRUCTURE_ID=735&layer_id=5370&refererLayerId=5354&train_num=002MJ&numtrain=002%D0%9C&src_code=2000000&departureDate=01.06.2014&firstStation=%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B0%20%D0%AF%D1%80&lastStation=%D0%92%D0%BB%D0%B0%D0%B4%D0%B8%D0%B2%D0%BE%D1%81%D1%82

Я заранее подготовил отдельный файл с городами, на маршруте, разбитом по дням. Это примерная разбивка, т.к. расписание меняется, так что не используйте мою таблицу для оценка когда вы приедете к любимой тёще в гости. Некоторые станции поезда не имеют аналога в нашей оригинальной таблице городов, поэтому они пропущена. Некоторые города указаны 2 раза, т.к. они являются конечной точкой одного дневного перегона и начальной другого дневного перегона.

Наш маршрут будет соединять города, а не вокзалы, так же он не будет совпадать с реальной железной дорогой. Это просто визуализация маршрута, а не инструмент навигации!

In [50]:
train_russia = pd.read_csv('https://gist.githubusercontent.com/lexnekr/2da07b5fc12b5be24068e4d68ed47ca5/raw/d6256765a3223282fbfec7e0b040cbfb21593fff/train_russia.scv')

fig = go.Figure(go.Scattermapbox(legendgroup="group",
                                 name='Города России',
                                 lat=cities['geo_lat'], 
                                 lon=cities['geo_lon'], 
                                 text=cities['city'],
                                 marker=dict(colorbar=dict(title="Год основания"),
                                             color=cities['foundation_year'].map(to_int_year),
                                             size=cities['population'].map(to_int_size))))

for df_for_today in train_russia.groupby(['day number']):
    fig.add_trace(go.Scattermapbox(name='День {}'.format(df_for_today[0]),
                                   mode = "lines",
                                   hoverinfo='skip',
                                   lat=df_for_today[1]['geo_lat'],
                                   lon=df_for_today[1]['geo_lon']))    

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(title='По России на поезде',
                  legend_orientation="h",
                  mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2))

fig.show()

Если мы хотим анимировать процесс появления маршрута по дням, то нам придётся использовать тот же приём, что и ранее с появлением нескольких графиков - заранее вывести все графики или их заглушки невидимыми, а потом на каждом фрейме и шаге слайдера делать их видимыми.

In [51]:
data = [go.Scattermapbox(legendgroup="group",
                         name='Города России',
                         lat=cities['geo_lat'], 
                         lon=cities['geo_lon'],
                         text=cities['city'],
                         marker=dict(colorbar=dict(title="Год основания"),
                                     color=cities['foundation_year'].map(to_int_year),
                                     size=cities['population'].map(to_int_size)))]
for df_for_today in train_russia.groupby(['day number']):
    data.append(go.Scattermapbox(visible=False,
                                 name='День {}'.format(df_for_today[0]),
                                 mode = "lines",
                                 hoverinfo='skip',
                                 lat=df_for_today[1]['geo_lat'],
                                 lon=df_for_today[1]['geo_lon']))    



fig = go.Figure(data)

frames=[]
for i in range(len(data)+1):
    temp_frame = go.Frame(name=str(i), data=data)

    for j in range(1, i):
        temp_frame['data'][j]['visible']=True
    
    
    frames.append(temp_frame)

steps = []
for i in range(len(data)):
    step = dict(
        label = str(i),
        method = "animate",
        args = [[str(i+1)]]
    )
    steps.append(step)

sliders = [dict(
    currentvalue = {"prefix": "День №", "font": {"size": 20}},
    len = 0.9,
    x = 0.1,
    pad = {"b": 10, "t": 50},
    steps = steps,
)]

map_center = go.layout.mapbox.Center(lat=(cities['geo_lat'].max()+cities['geo_lat'].min())/2, 
                                     lon=(cities['geo_lon'].max()+cities['geo_lon'].min())/2)
fig.update_layout(title='По России на поезде',
                  legend_orientation="h",
                  mapbox_style="open-street-map",
                  mapbox=dict(center=map_center, zoom=2),
                  updatemenus=[dict(direction="left",
                                    pad = {"r": 10, "t": 80},
                                    x = 0.1,
                                    xanchor = "right",
                                    y = 0,
                                    yanchor = "top",
                                    showactive=False,
                                    type="buttons", 
                                    buttons=[dict(label="▶", method="animate", args=[None, {"fromcurrent": True}]),
                                             dict(label="❚❚", method="animate", args=[[None], {"frame": {"duration": 0, "redraw": False},
                                                                                               "mode": "immediate",
                                                                                               "transition": {"duration": 0}}])])],
                  )


fig.layout.sliders = sliders
fig.frames = frames  

fig.show()

Безусловно мы разобрали далеко не все виды графиков Plotly. Однако, данного базового набора примеров должно быть достаточно чтобы понять принцип по которому все они работают.

С примерами других визуализаций можно ознакомиться тут - https://plotly.com/python/ (обратите внимание, что для каждой категории приведены далеко не все примеры, больше примеров всегда доступно по ссылке "More ..."

In [ ]:
 
In [ ]: